Homeโ€บzx-carwash
DRAFT

zx-carwash

JSON

๐Ÿ’ก Ide & Konsep

ZX Carwash โ€” konsep (revisi alur operasional)

Aplikasi kasir Android untuk usaha cuci mobil + kantin dalam satu outlet, multi-tenant.

Satu mobil = satu tagihan terbuka yang hidup dari datang sampai diambil. Kantin bisa menempel ke tagihan mobil atau dibayar sendiri.

Alur operasional yang dikunci (7 Nov 2026)

1. DATANG      Kasir: plat + jenis + layanan cuci  ->  [Masuk antrian]
               bon status "open" (belum bayar), Antrian "menunggu", TIKET cetak
2. SAAT ANTRI  Buka kartu mobil -> tambah makanan/minuman
                 [Bayar sekarang]        -> item ditandai lunas, tidak ikut tagihan cuci
                 [Masukkan ke tagihan]   -> menambah subtotal kantin mobil itu
               Kasir bisa langsung melayani mobil lain; kartu ini tetap hidup
3. CUCI        Di kartu: pilih TUKANG  ->  [Mulai]  (status "dikerjakan")
                                          [Selesai] (status "selesai")
               Komisi dihitung per tukang dari PaketLayanan.komisi x qty
4. BAYAR       [Bayar] di kartu -> rincian cuci + kantin + diskon
               metode, dibayar, kembalian -> STRUK nota -> status "bayar"
5. DITINGGAL   Mobil selesai tapi diambil sore: tetap di daftar "Belum dibayar"
               Cari plat -> [Bayar] saat pemilik datang

1. DATANG Kasir: plat + jenis + layanan cuci -> [Masuk antrian]

bon status "open" (belum bayar), Antrian "menunggu", TIKET cetak

2. SAAT ANTRI Buka kartu mobil -> tambah makanan/minuman

[Bayar sekarang] -> item ditandai lunas, tidak ikut tagihan cuci

[Masukkan ke tagihan] -> menambah subtotal kantin mobil itu

Kasir bisa langsung melayani mobil lain; kartu ini tetap hidup

3. CUCI Di kartu: pilih TUKANG -> [Mulai] (status "dikerjakan")

[Selesai] (status "selesai")

Komisi dihitung per tukang dari PaketLayanan.komisi x qty

4. BAYAR [Bayar] di kartu -> rincian cuci + kantin + diskon

metode, dibayar, kembalian -> STRUK nota -> status "bayar"

5. DITINGGAL Mobil selesai tapi diambil sore: tetap di daftar "Belum dibayar"

Cari plat -> [Bayar] saat pemilik datang


### Keputusan yang sudah dikunci
| Aspek | Keputusan |
|---|---|
| Model bisnis | SaaS multi-tenant |
| Platform kasir | Android (HP/tablet) |
| Offline | **Wajib** โ€” transaksi jalan tanpa internet, sync saat online |
| Struk | Printer thermal Bluetooth (58mm / 80mm) |
| Order kantin | Lewat kasir; kantin ikut tagihan mobil atau bayar sendiri |
| **Antrian dibuat** | **Saat masuk (sebelum bayar)**, bukan sesudah bayar |
| **Pit** | Ditentukan **saat mulai dikerjakan**, bukan saat masuk antrian |
| Kantin bayar sendiri | **Item di bon mobil, ditandai lunas** (bukan bon terpisah) |
| Tukang | Satu tukang utama per mobil (tahap awal) |
| **Laporan** | **Terpisah**: Kas Cuci vs Kas Kantin โ€” dua buku, dua saldo, satu shift |

### Tiga sumber pesanan
| Pelanggan | Jalur | Nomor |
|---|---|---|
| Cuci saja | Kasir -> Masuk antrian | `A-0001` |
| Cuci + makan | Kartu mobil -> + Pesanan | ikut `A-0001` |
| Makan saja (lepas) | Kasir -> Pesanan kantin | `K-0001` |

**Aturan:** tidak ada kolom penanda baru. Transaksi dianggap **kantin lepas** bila tidak punya item `tipe='cuci'`. Antrian hanya dibuat bila ada item cuci.

### Laporan keuangan dipisah
Kasir memegang **dua jenis uang** yang tidak boleh tercampur di tutup kasir:

Keputusan yang sudah dikunci

| Aspek | Keputusan |

|---|---|

| Model bisnis | SaaS multi-tenant |

| Platform kasir | Android (HP/tablet) |

| Offline | Wajib โ€” transaksi jalan tanpa internet, sync saat online |

| Struk | Printer thermal Bluetooth (58mm / 80mm) |

| Order kantin | Lewat kasir; kantin ikut tagihan mobil atau bayar sendiri |

| Antrian dibuat | Saat masuk (sebelum bayar), bukan sesudah bayar |

| Pit | Ditentukan saat mulai dikerjakan, bukan saat masuk antrian |

| Kantin bayar sendiri | Item di bon mobil, ditandai lunas (bukan bon terpisah) |

| Tukang | Satu tukang utama per mobil (tahap awal) |

| Laporan | Terpisah: Kas Cuci vs Kas Kantin โ€” dua buku, dua saldo, satu shift |

Tiga sumber pesanan

| Pelanggan | Jalur | Nomor |

|---|---|---|

| Cuci saja | Kasir -> Masuk antrian | A-0001 |

| Cuci + makan | Kartu mobil -> + Pesanan | ikut A-0001 |

| Makan saja (lepas) | Kasir -> Pesanan kantin | K-0001 |

Aturan: tidak ada kolom penanda baru. Transaksi dianggap kantin lepas bila tidak punya item tipe='cuci'. Antrian hanya dibuat bila ada item cuci.

Laporan keuangan dipisah

Kasir memegang dua jenis uang yang tidak boleh tercampur di tutup kasir:

KAS CUCI   = subtotal item tipe cuci + addon, dikurangi diskon
KAS KANTIN = subtotal item tipe kantin

KAS CUCI = subtotal item tipe cuci + addon, dikurangi diskon

KAS KANTIN = subtotal item tipe kantin


- Tab Laporan punya dua jenis tampilan: **Cuci** dan **Kantin**, masing-masing omzet, jumlah transaksi, dan metode bayar sendiri
- Tutup shift menampilkan **dua angka kas sistem** + dua kolom hitung fisik; selisih dihitung per jenis
- Struk tetap satu bila satu mobil memesan keduanya, tetapi baris TOTAL dipisah: `TOTAL CUCI` dan `TOTAL KANTIN`
| **Tata letak** | **Mockup A โ€” Terang ยท Biru Bersih** (dipilih 7 Nov 2026). Kasir hanya memulai; Antrian jadi pusat kerja; kantin menempel ke mobil atau pesanan lepas `K-`; laporan dua buku |
| Login | SSO Z One |
| Arah desain | **A โ€” Biru Bersih** (`#2563EB`, aksen `#EA580C`, kartu putih, radius 16px) |

### Status pengerjaan per 7 Nov 2026

| Tahap | Isi | Status |
|---|---|---|
| **1** | Tombol "Masuk antrian" + tiket cetak | โœ… rilis app **v0.6.29** (pilihan pit dihapus lagi) |
| **2** | Bayar dari kartu antrian + struk nota | โณ berikutnya |
| **3** | Tambah kantin ke tagihan terbuka + bayar terpisah per item | โณ |
| **4** | Pilih tukang + komisi per tukang + laporan bagi hasil | โณ |
| **5** | Daftar "Belum dibayar" + cari plat untuk mobil ditinggal | โณ |

**Sudah jalan di perangkat:** printer Bluetooth `RPP02N` (sambung otomatis), struk & tiket ESC/POS.
**Belum boleh dipakai produksi:** tombol "Masuk antrian" sudah membuat bon `open`, tetapi **belum ada cara membayarnya dari app** sampai Tahap 2 selesai.

### Dua repo
| Repo | Isi |
|---|---|
| `djvpri/zx-carwash` | Aplikasi Android (Kotlin + Compose) |
| `djvpri/zx-carwash-api` | Backend Fastify + Prisma + Postgres (private) |

Backend live: `http://bg7dxicf3ixlex0nzzussq6u.103.93.129.94.sslip.io` โ€” `/api/health` โ†’ `{"ok":true,"db":"up"}`
  • Tab Laporan punya dua jenis tampilan: Cuci dan Kantin, masing-masing omzet, jumlah transaksi, dan metode bayar sendiri
  • Tutup shift menampilkan dua angka kas sistem + dua kolom hitung fisik; selisih dihitung per jenis
  • Struk tetap satu bila satu mobil memesan keduanya, tetapi baris TOTAL dipisah: TOTAL CUCI dan TOTAL KANTIN

| Tata letak | Mockup A โ€” Terang ยท Biru Bersih (dipilih 7 Nov 2026). Kasir hanya memulai; Antrian jadi pusat kerja; kantin menempel ke mobil atau pesanan lepas K-; laporan dua buku |

| Login | SSO Z One |

| Arah desain | A โ€” Biru Bersih (#2563EB, aksen #EA580C, kartu putih, radius 16px) |

Status pengerjaan per 7 Nov 2026

| Tahap | Isi | Status |

|---|---|---|

| 1 | Tombol "Masuk antrian" + tiket cetak | โœ… rilis app v0.6.29 (pilihan pit dihapus lagi) |

| 2 | Bayar dari kartu antrian + struk nota | โณ berikutnya |

| 3 | Tambah kantin ke tagihan terbuka + bayar terpisah per item | โณ |

| 4 | Pilih tukang + komisi per tukang + laporan bagi hasil | โณ |

| 5 | Daftar "Belum dibayar" + cari plat untuk mobil ditinggal | โณ |

Sudah jalan di perangkat: printer Bluetooth RPP02N (sambung otomatis), struk & tiket ESC/POS.

Belum boleh dipakai produksi: tombol "Masuk antrian" sudah membuat bon open, tetapi belum ada cara membayarnya dari app sampai Tahap 2 selesai.

Dua repo

| Repo | Isi |

|---|---|

| djvpri/zx-carwash | Aplikasi Android (Kotlin + Compose) |

| djvpri/zx-carwash-api | Backend Fastify + Prisma + Postgres (private) |

Backend live: http://bg7dxicf3ixlex0nzzussq6u.103.93.129.94.sslip.io โ€” /api/health โ†’ {"ok":true,"db":"up"}

Ringkasan

ZX Carwash = aplikasi kasir Android untuk usaha cuci mobil yang juga punya kantin.

Masalah yang diselesaikan:

  • Kasir sekarang biasanya pakai buku / Excel โ†’ omzet cuci dan omzet kantin tercampur, tidak ada rekap per shift.
  • Pelanggan nyuci sambil makan โ†’ dua tagihan terpisah, kasir harus hitung dua kali.
  • Internet di lokasi cuci sering putus โ†’ aplikasi online-only bikin antrian macet.
  • Komisi tukang dihitung manual dari jumlah mobil โ†’ rawan selisih.

Solusi:

  • Satu aplikasi, dua modul (Cuci, Kantin), satu bon gabungan.
  • Antrian per bay dengan timer โ†’ komisi otomatis.
  • Database lokal SQLite + outbox sync โ†’ transaksi tetap jalan offline.
  • Struk thermal 58/80mm tercetak langsung dari HP kasir via Bluetooth.
  • Login SSO ZOne, tenant otomatis ter-scope: kasir hanya melihat data outlet-nya.

Status 29 Okt 2026:

  • Backend live โ€” Fastify + Prisma + Postgres, 27 cek end-to-end lulus (e2e.js).
  • APK masih fase 1: kasir bon gabungan, struk thermal, printer Bluetooth, pengaturan usaha, cek update. Belum login SSO, belum kirim outbox.

Stack & Arsitektur

Android (kasir) โ€” yang SUDAH ada

  • Kotlin + Jetpack Compose (Material 3), minSdk 26, targetSdk 36, com.zomet.zxcarwash.
  • SQLite mentah via SQLiteOpenHelper โ€” bukan Room. Alasan: fase 1 hanya butuh tabel bon + item + outbox, Room menambah KSP/annotation processor tanpa manfaat nyata di tahap ini.
  • Outbox lokal (sync_outbox di SQLite) โ€” baris bon ditulis lokal dulu, ditandai belum terkirim.
  • Transport: HttpURLConnection bawaan Android, bukan Retrofit/OkHttp. Satu file Api.kt, tanpa dependency jaringan.
  • Struk: Struk.kt murni fungsi (bisa diuji unit test), 32 kolom monospace.
  • Printer: ESC/POS Bluetooth SPP (bt/BluetoothPrinter.kt) โ€” driver kecil sendiri, pairing + monitor putus + reconnect.
  • Pengaturan usaha disimpan lokal, dipakai header/footer struk, tetap benar saat offline.
  • Cek update: GitHub Releases (update/Updater.kt), auto-cek saat app dibuka + tombol manual.

Yang belum di app: login SSO, antrian per bay, shift, member, stok, laporan, dan pengiriman outbox ke server.

Backend (SaaS multi-tenant) โ€” SUDAH LIVE

  • Fastify 4 + Prisma 5 + Postgres, Node 20. Dipilih mengikuti pola acumedis-api supaya cara deploy Coolify & auth seragam di ekosistem Zomet.
  • Auth: token Z One (HS256, CROSS_APP_SECRET) โ†’ POST /v1/auth/sso/exchange โ†’ JWT sesi ZX Carwash berisi tenantId + outletId. Semua query di-scope dari token, tidak pernah dari body.
  • Sync idempoten: klien kirim clientId (UUID); Transaksi.clientId unik. Kiriman ulang dibalas status: "duplikat" + serverId yang sama, bukan baris kedua.
  • Nomor bon dibuat server: <huruf outlet>-<urut harian> (mis. CC-0001). Bentrok โ†’ server memberi nomor baru bersufiks R.
  • Angka tidak dipercaya dari perangkat: subtotal, total, kembali dihitung ulang dari daftar item.
  • Stok kantin dipotong sekali saat bayar, dibalik saat batal; penanda StokMutasi.ref = "bon:<id>" mencegah potong ganda.
  • Rentang laporan pakai offset menit (tz=420 WIB), bukan batas hari UTC.
  • Lisensi bertanda tangan HMAC CROSS_APP_SECRET; requireWritable hanya mengunci endpoint yang menambah data โ€” baca, ubah status bayar, dan cetak struk tetap jalan saat langganan habis.
  • Provisioning tenant dari Z One lewat POST /v1/admin/tenant + /v1/admin/user (header X-Cross-App-Secret).

Alur offline

1. Kasir buka app โ†’ token sesi tersimpan โ†’ data master dari SQLite lokal (bukan menunggu jaringan).

2. Transaksi baru โ†’ tulis bon + item lokal, masuk sync_outbox (status pending).

3. Struk langsung dicetak dari data lokal.

4. Online โ†’ app POST /v1/sync/batch (maks 50 bon per batch, urut created_at), server balas mapping clientId โ†’ serverId + nomorBon.

5. Pull master (GET /v1/master?since=) saat app resume; GET /v1/master/versi untuk cek murah sebelum tarik penuh.

Pengganti rencana awal: Room โ†’ SQLite mentah, WorkManager โ†’ pemicu saat app resume + tombol, Retrofit โ†’ HttpURLConnection. Semua karena fase 1 belum butuh mesin itu, dan menambah dependency di app kasir berarti APK lebih besar + lebih banyak yang bisa rusak di HP murah.

Data Model

Semua tabel operasional punya tenantId (+ outletId) dan kolom clientId untuk idempotensi sync. Schema nyata ada di prisma/schema.prisma โ€” 17 model.

Tenant(id, nama, plan, langgananSampai, aktif, lisensi)
Outlet(id, tenantId, nama, alamat, telepon, timezone)
User(id, tenantId, email @unique, nama, role[owner|admin|kasir|tukang], pinHash, aktif)
Bay(id, outletId, nama, aktif)
Kendaraan(id, tenantId, plat, jenis, model, memberId)      @@unique([tenantId, plat])
Member(id, tenantId, nama, telepon, poin, saldo, level)
PaketLayanan(id, tenantId, nama, durasi, harga Json, komisi, aktif)
Addon(id, tenantId, nama, harga, aktif)
Produk(id, tenantId, nama, kategori, harga, stok, trackStok, aktif)
Transaksi(id, tenantId, outletId, nomorBon, shiftId, kasirId, memberId, bayId,
          plat, jenis, status[open|bayar|batal], subtotalCuci, subtotalKantin,
          diskon, total, metode[tunai|qris|kartu|transfer], dibayar, kembali,
          catatan, clientId @unique, createdAt, syncedAt)
TransaksiItem(id, transaksiId, tipe[cuci|addon|kantin], refId, nama, harga, qty, subtotal)
Antrian(id, transaksiId, bayId, status[menunggu|dikerjakan|selesai|diambil],
        mulaiAt, selesaiAt, tukangId, komisi)
Shift(id, tenantId, outletId, kasirId, bukaAt, tutupAt, kasAwal, kasSistem,
      kasFisik, selisih, catatan, clientId)
Pembayaran(id, transaksiId, metode, jumlah, ref, createdAt)
StokMutasi(id, produkId, tipe[masuk|keluar|koreksi], qty, ref, createdAt)
SyncOutbox(id, tenantId, deviceId, entitas, clientId, payload Json, status, retry, lastError)
Pengaturan(id, tenantId, outletId, namaUsaha, alamat, noHp, catatanNota, autoUpdate)

Tenant(id, nama, plan, langgananSampai, aktif, lisensi)

Outlet(id, tenantId, nama, alamat, telepon, timezone)

User(id, tenantId, email @unique, nama, role[owner|admin|kasir|tukang], pinHash, aktif)

Bay(id, outletId, nama, aktif)

Kendaraan(id, tenantId, plat, jenis, model, memberId) @@unique([tenantId, plat])

Member(id, tenantId, nama, telepon, poin, saldo, level)

PaketLayanan(id, tenantId, nama, durasi, harga Json, komisi, aktif)

Addon(id, tenantId, nama, harga, aktif)

Produk(id, tenantId, nama, kategori, harga, stok, trackStok, aktif)

Transaksi(id, tenantId, outletId, nomorBon, shiftId, kasirId, memberId, bayId,

plat, jenis, status[open|bayar|batal], subtotalCuci, subtotalKantin,

diskon, total, metode[tunai|qris|kartu|transfer], dibayar, kembali,

catatan, clientId @unique, createdAt, syncedAt)

TransaksiItem(id, transaksiId, tipe[cuci|addon|kantin], refId, nama, harga, qty, subtotal)

Antrian(id, transaksiId, bayId, status[menunggu|dikerjakan|selesai|diambil],

mulaiAt, selesaiAt, tukangId, komisi)

Shift(id, tenantId, outletId, kasirId, bukaAt, tutupAt, kasAwal, kasSistem,

kasFisik, selisih, catatan, clientId)

Pembayaran(id, transaksiId, metode, jumlah, ref, createdAt)

StokMutasi(id, produkId, tipe[masuk|keluar|koreksi], qty, ref, createdAt)

SyncOutbox(id, tenantId, deviceId, entitas, clientId, payload Json, status, retry, lastError)

Pengaturan(id, tenantId, outletId, namaUsaha, alamat, noHp, catatanNota, autoUpdate)


### Aturan yang dijaga schema
- `@@unique([tenantId, nomorBon])` โ€” pengaman terakhir nomor bon kembar.
- `Transaksi.clientId @unique` โ€” inti idempotensi sync.
- `@@unique([tenantId, outletId])` pada `Pengaturan` โ€” satu pengaturan per outlet.
- `@@unique([entitas, clientId])` pada `SyncOutbox` โ€” audit sync tidak menumpuk saat retry.

### `refId` di `TransaksiItem` SENGAJA bukan foreign key
Satu bon mencampur tiga jenis item, dan tiap jenis menunjuk tabel berbeda: `cuci` โ†’ `PaketLayanan`, `addon` โ†’ `Addon`, `kantin` โ†’ `Produk`. Kolom generik tidak bisa menjadi FK ke satu tabel saja. Percobaan pertama memaksa `refId โ†’ Produk`, hasilnya `Foreign key constraint violated: TransaksiItem_refId_fkey` setiap sync bon yang berisi jasa cuci. Sekarang `refId` hanya `String?` berindeks; integritas dijaga di sisi aplikasi.

### Aturan hitung
- `total = subtotalCuci + subtotalKantin - diskon` (tidak boleh negatif)
- `subtotalCuci` = item `cuci` + `addon`; `subtotalKantin` = item `kantin`
- Komisi per antrian = `PaketLayanan.komisi ร— qty item cuci`, bisa ditimpa lewat body `PATCH /v1/antrian/:id`
- Stok kantin berkurang saat transaksi `bayar`, dibalik saat `batal`

Aturan yang dijaga schema

  • @@unique([tenantId, nomorBon]) โ€” pengaman terakhir nomor bon kembar.
  • Transaksi.clientId @unique โ€” inti idempotensi sync.
  • @@unique([tenantId, outletId]) pada Pengaturan โ€” satu pengaturan per outlet.
  • @@unique([entitas, clientId]) pada SyncOutbox โ€” audit sync tidak menumpuk saat retry.

`refId` di `TransaksiItem` SENGAJA bukan foreign key

Satu bon mencampur tiga jenis item, dan tiap jenis menunjuk tabel berbeda: cuci โ†’ PaketLayanan, addon โ†’ Addon, kantin โ†’ Produk. Kolom generik tidak bisa menjadi FK ke satu tabel saja. Percobaan pertama memaksa refId โ†’ Produk, hasilnya Foreign key constraint violated: TransaksiItem_refId_fkey setiap sync bon yang berisi jasa cuci. Sekarang refId hanya String? berindeks; integritas dijaga di sisi aplikasi.

Aturan hitung

  • total = subtotalCuci + subtotalKantin - diskon (tidak boleh negatif)
  • subtotalCuci = item cuci + addon; subtotalKantin = item kantin
  • Komisi per antrian = PaketLayanan.komisi ร— qty item cuci, bisa ditimpa lewat body PATCH /v1/antrian/:id
  • Stok kantin berkurang saat transaksi bayar, dibalik saat batal

API Endpoints

Base: http://bg7dxicf3ixlex0nzzussq6u.103.93.129.94.sslip.io

Auth: Authorization: Bearer <token ZX Carwash> โ€” tenant & outlet diambil dari claim token, bukan dari body.

Publik (tanpa token)

| Method | Path | Keterangan |

|---|---|---|

| GET | / | identitas service + versi |

| GET | /api/health | {ok, service, version, db} โ€” db:"up" berarti Postgres terhubung |

| POST | /v1/auth/sso/exchange | {token, deviceId?, outletId?} โ†’ {accessToken, user, tenant, outletId} |

| GET/POST | /api/admin/cross-app | daftar tenant untuk hub Z One (header X-Cross-App-Secret) |

Auth & sesi

| Method | Path | Keterangan |

|---|---|---|

| GET | /v1/auth/me | profil, role, tenant, outlet |

| GET | /v1/license | {aktif, plan, sampai, sisaHari, maxOutlet, tandaTanganSah} |

Master (pull untuk cache offline)

| Method | Path | Keterangan |

|---|---|---|

| GET | /v1/master?since=&outletId= | paket, addon, produk, member, kendaraan, bay, users, pengaturan + versi |

| GET | /v1/master/versi | sidik jari isi master โ€” cek murah sebelum tarik penuh |

| GET | /v1/pengaturan | nama usaha, alamat, no HP, catatan nota |

| PATCH | /v1/pengaturan | ubah field pengaturan (upsert) |

Sync (push dari perangkat)

| Method | Path | Keterangan |

|---|---|---|

| POST | /v1/sync/batch | {deviceId, outletId?, items:[{entitas:"bon", clientId, payload}]} โ†’ {berhasil, gagal, hasil:[{clientId, serverId, nomorBon, status, total}]} ยท maks 50 bon/batch |

| GET | /v1/sync/status | jumlah outbox pending/gagal + waktu sync terakhir |

Bon & antrian

| Method | Path | Keterangan |

|---|---|---|

| GET | /v1/transaksi?tanggal=&status=&limit= | daftar bon + total |

| GET | /v1/transaksi/:id | bon + item + antrian + pembayaran |

| POST | /v1/transaksi/:id/bayar | {metode, dibayar} โ€” memicu potong stok + antrian selesai |

| POST | /v1/transaksi/:id/batal | {alasan} โ€” stok dibalik, pembayaran dihapus |

| POST | /v1/transaksi/:id/antrian | {bayId, tukangId} |

| GET | /v1/antrian?status=&bayId= | papan antrian per bay |

| PATCH | /v1/antrian/:id | {status, tukangId?, komisi?} โ€” isi mulaiAt/selesaiAt + komisi |

Shift & kas

| Method | Path | Keterangan |

|---|---|---|

| GET | /v1/shift?limit= | riwayat + shift aktif |

| POST | /v1/shift/buka | {kasAwal} โ€” 409 kalau masih ada shift terbuka |

| POST | /v1/shift/tutup | {kasFisik, catatan?} โ€” hitung kasSistem, selisih |

| GET | /v1/shift/:id/rekap | omzet per metode, kas sistem, selisih |

Master data (diubah dari app)

| Method | Path | Keterangan |

|---|---|---|

| GET/POST | /v1/paket ยท /v1/addon ยท /v1/produk ยท /v1/bay ยท /v1/member | daftar & simpan (id ada = ubah) |

| POST | /v1/stok/mutasi | {produkId, tipe, qty} masuk/keluar/koreksi |

| GET | /v1/stok/mutasi?produkId= | riwayat mutasi stok |

| POST | /v1/member/:id/saldo | {poin, saldo} โ€” nilai negatif boleh |

Laporan

| Method | Path | Keterangan |

|---|---|---|

| GET | /v1/laporan/harian?tanggal=&tz=420 | omzet, cuci vs kantin, per metode, paket terlaris, jam sibuk, mobil, komisi |

| GET | /v1/laporan/periode?dari=&sampai= | tren harian, item terlaris, komisi per tukang |

Admin (dipakai Z One)

| Method | Path | Keterangan |

|---|---|---|

| POST | /v1/admin/tenant | provisioning/perbarui tenant + outlet (idempoten per nama) |

| POST | /v1/admin/user | upsert user tenant |

| GET | /v1/admin/user?tenantId= | daftar user |

Kode balasan

401 token tidak ada/rusak ยท 402 langganan habis (hanya endpoint tulis) ยท 403 token app lain atau akun nonaktif ยท 404 bukan milik tenant ini ยท 409 shift masih terbuka / bon batal ยท 413 batch > 50 ยท 200/201 sukses.

Fitur Fase 1

Status per 29 Okt 2026: โœ… ada ยท โณ belum

Backend โ€” โœ… semua endpoint ini sudah live & lulus uji

  • SSO Z One โ€” token HS256 dari Z One ditukar jadi sesi ZX Carwash. Email harus sudah terdaftar; email tak dikenal ditolak dengan pesan jelas (bukan user baru otomatis).
  • Master offline โ€” satu response berisi paket, addon, produk, member, kendaraan, bay, user, pengaturan + versi.
  • Sync bon idempoten โ€” clientId unik; kirim ulang โ†’ duplikat + serverId sama.
  • Nomor bon dari server โ€” CC-0001; bentrok โ†’ sufiks R.
  • Hitung ulang di server โ€” subtotal, total, kembali tidak dipercaya dari perangkat.
  • Stok kantin โ€” potong sekali saat bayar, balik saat batal.
  • Antrian per bay + komisi โ€” dari PaketLayanan.komisi ร— qty.
  • Shift โ€” buka, tutup (kas sistem vs fisik + selisih), rekap per metode.
  • Laporan โ€” harian (jam sibuk, paket terlaris) & periode (tren, terlaris, komisi per tukang).
  • Master data โ€” paket, addon, produk, bay, member, mutasi stok.
  • Lisensi HMAC + requireWritable yang hanya mengunci endpoint tulis.
  • Provisioning tenant/user dari Z One.

Android โ€” sudah ada

  • Kasir bon gabungan (cuci + add-on + kantin), diskon bon, kembalian.
  • Simpan ke SQLite lokal + baris outbox (belum dikirim ke server).
  • Struk thermal ESC/POS 58mm + pratinjau 32 kolom.
  • Printer Bluetooth SPP: pairing, monitor putus, reconnect.
  • Pengaturan usaha (nama, alamat, no HP, catatan nota) โ†’ header/footer struk.
  • Lisensi (paket, berlaku sampai, sisa, ID) โ€” tampil saja, belum ditegakkan.
  • Cek update manual + auto-cek saat app dibuka.

Android โ€” belum

| Fitur | Catatan |

|---|---|

| Login SSO Z One | server siap; app belum ada layar login |

| Kirim outbox ke /v1/sync/batch | outbox sudah ditulis, tapi tidak pernah dikirim |

| Tarik master dari /v1/master | app masih memakai master statis di Data.kt |

| Antrian per bay + timer | server siap |

| Buka/tutup shift | server siap |

| Member, poin, deposit | server siap |

| Stok kantin + mutasi | server siap |

| Laporan harian & periode | server siap |

Modul Cuci (rencana)

  • Paket layanan (reguler/salon/premium/mesin/interior) dengan harga per jenis kendaraan.
  • Add-on cepat: jamur kaca, parfum, poles, wax.
  • Input plat + jenis kendaraan โ†’ riwayat per plat.
  • Pilih bay โ†’ antrian otomatis; status menunggu โ†’ dikerjakan โ†’ selesai โ†’ diambil.
  • Timer per mobil, assign tukang, komisi otomatis per selesai.

Modul Kantin (rencana)

  • Master menu + kategori + harga, toggle habis.
  • Order lewat kasir (take away / antar ke meja), tanpa QR mandiri.
  • Stok berkurang saat bayar, koreksi manual, stok kritis di dashboard.
  • Item kantin bisa digabung ke bon cuci yang sedang berjalan.

Kasir & pembayaran (rencana)

  • Diskon level bon, diskon member.
  • Metode: tunai, QRIS, kartu, transfer.
  • Split pembayaran dalam satu bon.
  • Batal/void dengan alasan, tercatat.

Desain UI (A โ€” Biru Bersih)

Arah desain terpilih: A โ€” Biru Bersih.

Token warna:

--primary: #2563EB      --on-primary: #FFFFFF
--accent:  #EA580C      --accent-fg:  #B45309
--bg:      #F8FAFC      --card:      #FFFFFF
--fg:      #1E293B      --muted-fg:  #475569
--border:  #E2E8F0      --ok: #15803D (chip selesai)

--primary: #2563EB --on-primary: #FFFFFF

--accent: #EA580C --accent-fg: #B45309

--bg: #F8FAFC --card: #FFFFFF

--fg: #1E293B --muted-fg: #475569

--border: #E2E8F0 --ok: #15803D (chip selesai)

- Tipografi: Plus Jakarta Sans (700/800 untuk angka total), angka tabular.
- Radius: 16px kartu, 14px tombol, 26px frame HP.
- Navigasi bawah 4 tab: Kasir ยท Antrian ยท Menu ยท Laporan.
- Struk: monospace, dipisah per modul supaya omzet cuci vs kantin terbaca dari kertas.
- Prinsip: angka total paling besar di layar; status sync selalu kelihatan; satu tangan bisa operasikan.

Layar kunci: Kasir (bill gabungan), Antrian & Bay, Pembayaran + Struk. Lihat bagian **Mockup**.
  • Tipografi: Plus Jakarta Sans (700/800 untuk angka total), angka tabular.
  • Radius: 16px kartu, 14px tombol, 26px frame HP.
  • Navigasi bawah 4 tab: Kasir ยท Antrian ยท Menu ยท Laporan.
  • Struk: monospace, dipisah per modul supaya omzet cuci vs kantin terbaca dari kertas.
  • Prinsip: angka total paling besar di layar; status sync selalu kelihatan; satu tangan bisa operasikan.

Layar kunci: Kasir (bill gabungan), Antrian & Bay, Pembayaran + Struk. Lihat bagian Mockup.

Mockup A - Biru Bersih (terpilih)

Mockup A - Biru Bersih (terpilih)

Mockup B - Teal Cepat

Mockup B - Teal Cepat

Mockup C - Bay Gelap

Mockup C - Bay Gelap

Mockup A - Pengaturan, lisensi, update

Mockup A - Pengaturan, lisensi, update

Pitfall & Catatan

Pitfall yang benar-benar sudah menggigit (bukan teori)

  • refId dipaksa jadi FK ke Produk โ†’ sync gagal total.** Item bon mencampur jasa cuci (โ†’ PaketLayanan), add-on (โ†’ Addon), dan kantin (โ†’ Produk). Satu kolom generik tidak bisa FK ke satu tabel. Gejalanya Foreign key constraint violated: TransaksiItem_refId_fkey, dan karena dibungkus transaksi DB, seluruh bon gagal โ€” bukan cuma itemnya. Solusi: refId String? berindeks, tanpa relasi.
  • Tanggal "hari ini" dihitung UTC โ†’ laporan kosong. Pukul 04:00 UTC tanggal 29 = 11:00 WIB tanggal 29, tapi new Date().toISOString().slice(0,10) menghasilkan tanggal 28. Laporan harian tampak bon=0 padahal bon baru saja dibuat. Rumus yang benar: hariLokal = new Date(now + tzMenit*60000).toISOString().slice(0,10), lalu gte = hariLokal 00:00 UTC โˆ’ tzMenit.
  • Komisi 0 untuk bon yang dibayar langsung. Antrian dibuat dengan status selesai saat bon bayar, jadi selesaiAt sudah terisi dan blok perhitungan komisi (if (!selesaiAt)) terlewati. Perhitungan komisi harus dipisah dari pengisian selesaiAt.
  • Laporan menghitung mobil dari bon batal. Query antrian hanya memfilter tenantId, sehingga bon yang dibatalkan tetap terhitung sebagai mobil tercuci. Wajib transaksi: { tenantId, status: 'bayar' }.
  • **Env DATABASE_URL Coolify berisi password salah.** Password Postgres 64 karakter, tapi URL yang terpasang memakai potongan 23 karakter. health balas db:"down" dan Prisma: Authentication failed ... credentials for postgres are not valid. Fix: ambil postgres_password dari API database Coolify, lalu PATCH /api/v1/applications/<uuid>/envs dengan body {key, value, is_preview} โ€” bukan PATCH .../envs/<envUuid> (itu balas 404).
  • PATCH /envs menambah baris, tidak menimpa.** Setelah perbaikan URL ada 2 baris DATABASE_URL; container memakai yang mana pun yang terakhir. Hapus duplikat lewat DELETE /envs/<uuid>, sisakan satu.
  • Nomor bon jangan dibuat di device. Dua kasir menghasilkan nomor sama. @@unique([tenantId, nomorBon]) menangkapnya; sync menangkap P2002 lalu memberi nomor bersufiks R alih-alih gagal total.
  • Offline dulu, bukan offline nanti. Kalau transaksi menulis ke server dulu, app mati saat sinyal hilang. Tulis lokal, sync belakangan.
  • Idempotensi sync wajib. Tanpa clientId unik, retry = bon dobel.
  • Stok kantin hanya untuk item kantin. Jangan potong stok untuk add-on jasa.
  • Tutup shift harus mengunci. Transaksi setelah tutup shift = selisih kas tak terlacak.
  • Printer Bluetooth: banyak printer murah butuh ESC/POS raw via SPP โ€” tes potong kertas & lebar 58 vs 80 sebelum rilis.
  • SSO token expiry: token Z One hanya 5 menit (untuk ditukar), sesi ZX Carwash 60 menit. Jangan kunci kasir saat sesi habis โ€” transaksi offline harus tetap jalan.
  • Multi-tenant leak: setiap query wajib filter tenantId dari token; jangan percaya tenantId dari body request.

Catatan uji

  • npm test โ†’ 13 test murni tanpa DB (auth, hitung bon, rentang WIB, lisensi HMAC, header admin).
  • node e2e.js โ†’ 27 cek terhadap server live: SSO sah/palsu/app-salah, master, shift, sync, idempotensi, stok, komisi, laporan, rekap, batal, lisensi, cross-app. Semua lulus 29 Okt 2026.

Pengaturan Usaha & Update Aplikasi

Pengaturan usaha (dipakai struk)

Field yang bisa diubah user:

| Field | Contoh | Dipakai di |

|---|---|---|

| Nama usaha | CUCI KANTIN ZOMET | header struk, layar login/beranda |

| Alamat usaha | Jl. Melati 12, Pontianak | header struk (2 baris, wrap otomatis) |

| Nomor HP | 0812-3456-789 | header struk + tombol "Hubungi" |

| Catatan nota | Terima kasih, barang tertinggal bukan tanggung jawab kami | footer struk, 2โ€“3 baris |

Aturan:

  • Disimpan di pengaturan (scope outlet_id), cache lokal di Room โ†’ struk tetap benar saat offline.
  • Perubahan langsung berlaku pada struk berikutnya (tidak perlu restart, tidak perlu online).
  • Struk thermal 58mm: nama usaha maks 20 karakter efektif per baris, alamat wrap ke 2 baris, catatan nota dipotong setelah 3 baris + elipsis (...).
  • Logo usaha opsional (fase 2) โ€” cetak grayscale 1-bit, tinggi maks 48 dot.
pengaturan(id, tenant_id, outlet_id, nama_usaha, alamat, no_hp, catatan_nota,
           logo_url?, auto_update BOOLEAN, updated_at)

pengaturan(id, tenant_id, outlet_id, nama_usaha, alamat, no_hp, catatan_nota,

logo_url?, auto_update BOOLEAN, updated_at)


### Update aplikasi

Dua jalur distribusi, dua mekanisme:

**A. APK sideload (unduh langsung / share ke kasir)**
- `GET /app/versi?platform=android&version_code=<n>` โ†’ `{version_code, version_name, min_supported, url_apk, changelog[], wajib:boolean}`.
- Auto-check: saat app dibuka + tiap 24 jam (WorkManager, hanya saat online). Ada versi baru โ†’ dialog changelog + tombol **Update sekarang**.
- Tombol manual **"Cek update terbaru"** di Pengaturan โ†’ panggil endpoint yang sama, tampilkan hasil eksplisit: "Sudah versi terbaru (0.1.4)" atau "Versi 0.1.5 tersedia".
- Unduh APK โ†’ verifikasi `sha256` dari response โ†’ buka installer via `FileProvider` + `REQUEST_INSTALL_PACKAGES`.
- `wajib: true` (versi di bawah `min_supported`) โ†’ dialog tidak bisa ditutup; `min_supported` mencegah kasir pakai versi yang format sync-nya sudah tidak didukung server.

**B. Play Store (kalau dirilis ke Play)**
- **Play Core In-App Updates**: `appUpdateManager.startUpdateFlow()` mode `FLEXIBLE` (default) atau `IMMEDIATE` bila `wajib`.
- Tombol "Cek update terbaru" โ†’ `appUpdateInfo.updateAvailability()`; kalau `UPDATE_NOT_AVAILABLE` tampilkan pesan itu apa adanya, jangan fake progress.
- Auto update di Play Store diurus Play; toggle `auto_update` di app hanya relevan untuk jalur APK.

Pitfall:
- Jangan auto-install diam-diam saat ada transaksi berjalan / shift belum ditutup โ€” jadwalkan setelah tutup shift atau saat app idle.
- Auto-check jangan blokir start app: jalan di background, UI tetap bisa dipakai offline.
- Update APK menghapus sesi kalau signature berubah โ€” pakai keystore yang sama terus (simpan di CI secret).
- Cek versi butuh jaringan; saat offline tombol harus bilang "butuh koneksi", bukan spinner menggantung.

### Footer struk (format tetap)

Urutan dari atas ke bawah โ€” bagian atas/identitas mengikuti Pengaturan, bagian bawah **fixed** dari app:

Update aplikasi

Dua jalur distribusi, dua mekanisme:

A. APK sideload (unduh langsung / share ke kasir)

  • GET /app/versi?platform=android&version_code=<n> โ†’ {version_code, version_name, min_supported, url_apk, changelog[], wajib:boolean}.
  • Auto-check: saat app dibuka + tiap 24 jam (WorkManager, hanya saat online). Ada versi baru โ†’ dialog changelog + tombol Update sekarang.
  • Tombol manual "Cek update terbaru" di Pengaturan โ†’ panggil endpoint yang sama, tampilkan hasil eksplisit: "Sudah versi terbaru (0.1.4)" atau "Versi 0.1.5 tersedia".
  • Unduh APK โ†’ verifikasi sha256 dari response โ†’ buka installer via FileProvider + REQUEST_INSTALL_PACKAGES.
  • wajib: true (versi di bawah min_supported) โ†’ dialog tidak bisa ditutup; min_supported mencegah kasir pakai versi yang format sync-nya sudah tidak didukung server.

B. Play Store (kalau dirilis ke Play)

  • Play Core In-App Updates: appUpdateManager.startUpdateFlow() mode FLEXIBLE (default) atau IMMEDIATE bila wajib.
  • Tombol "Cek update terbaru" โ†’ appUpdateInfo.updateAvailability(); kalau UPDATE_NOT_AVAILABLE tampilkan pesan itu apa adanya, jangan fake progress.
  • Auto update di Play Store diurus Play; toggle auto_update di app hanya relevan untuk jalur APK.

Pitfall:

  • Jangan auto-install diam-diam saat ada transaksi berjalan / shift belum ditutup โ€” jadwalkan setelah tutup shift atau saat app idle.
  • Auto-check jangan blokir start app: jalan di background, UI tetap bisa dipakai offline.
  • Update APK menghapus sesi kalau signature berubah โ€” pakai keystore yang sama terus (simpan di CI secret).
  • Cek versi butuh jaringan; saat offline tombol harus bilang "butuh koneksi", bukan spinner menggantung.

Footer struk (format tetap)

Urutan dari atas ke bawah โ€” bagian atas/identitas mengikuti Pengaturan, bagian bawah fixed dari app:

        <NAMA USAHA>              <- pengaturan.nama_usaha
   <alamat usaha>                  <- pengaturan.alamat (wrap 2 baris)
      <no HP>                      <- pengaturan.no_hp
--------------------------------
<item transaksi>
--------------------------------
TOTAL                  <total>
<metode bayar>         <jumlah>
--------------------------------
<catatan_nota>                     <- pengaturan.catatan_nota, maks 3 baris
--------------------------------
          TERIMA KASIH             <- FIXED, tidak bisa diubah user
    Powered by ZX Carwash App      <- FIXED, tidak bisa diubah user
       <nomor bon> ยท <jam>

<NAMA USAHA> <- pengaturan.nama_usaha

<alamat usaha> <- pengaturan.alamat (wrap 2 baris)

<no HP> <- pengaturan.no_hp

--------------------------------

<item transaksi>

--------------------------------

TOTAL <total>

<metode bayar> <jumlah>

--------------------------------

<catatan_nota> <- pengaturan.catatan_nota, maks 3 baris

--------------------------------

TERIMA KASIH <- FIXED, tidak bisa diubah user

Powered by ZX Carwash App <- FIXED, tidak bisa diubah user

<nomor bon> ยท <jam>


Aturan:
- `TERIMA KASIH` dicetak **letter-spaced** (1 spasi antar huruf) + bold, ukuran 1 tingkat di atas body.
- `Powered by ZX Carwash App` ukuran kecil (font B / 9โ€“10 dot), rata tengah, selalu baris terakhir sebelum nomor bon.
- Dua baris itu **konstanta di template struk**, bukan field pengaturan โ€” user tidak bisa menghapus/mengubah (branding).
- Kalau `catatan_nota` kosong, blok catatan dilewati; `TERIMA KASIH` + `Powered by` tetap tercetak.
- Struk 58mm: `Powered by ZX Carwash App` = 25 karakter, aman 1 baris pada font B; jangan di-wrap.

Aturan:

  • TERIMA KASIH dicetak letter-spaced (1 spasi antar huruf) + bold, ukuran 1 tingkat di atas body.
  • Powered by ZX Carwash App ukuran kecil (font B / 9โ€“10 dot), rata tengah, selalu baris terakhir sebelum nomor bon.
  • Dua baris itu konstanta di template struk, bukan field pengaturan โ€” user tidak bisa menghapus/mengubah (branding).
  • Kalau catatan_nota kosong, blok catatan dilewati; TERIMA KASIH + Powered by tetap tercetak.
  • Struk 58mm: Powered by ZX Carwash App = 25 karakter, aman 1 baris pada font B; jangan di-wrap.

Alur Operasional (7 Tahap)

Alur operasional โ€” satu mobil, satu tagihan terbuka

Inti perubahan: antrian dibuat saat mobil datang, bukan sesudah bayar. Bon hidup beberapa jam.

Diagram status

                    +-------------------+
  Kasir: Masuk      |  Transaksi "open" |      (uang belum diterima)
  antrian  -------> |  Antrian menunggu |
                    +---------+---------+
                              |
              +---------------+----------------+
              |                                |
      tambah kantin                    [Mulai] -> pilih tukang + pit kosong
      (bayar sendiri /                 Antrian "dikerjakan"
       masuk tagihan)                  (pit tercatat di sini)
              |                                |
              |                          [Selesai]
              |                                |
              |                                v
              |                        Antrian "selesai"
              |                                |
              +-----------> [Bayar] <----------+
                                |
                                v
                    Transaksi "bayar" + Pembayaran
                    Antrian "diambil"

+-------------------+

Kasir: Masuk | Transaksi "open" | (uang belum diterima)

antrian -------> | Antrian menunggu |

+---------+---------+

|

+---------------+----------------+

| |

tambah kantin [Mulai] -> pilih tukang + pit kosong

(bayar sendiri / Antrian "dikerjakan"

masuk tagihan) (pit tercatat di sini)

| |

| [Selesai]

| |

| v

| Antrian "selesai"

| |

+-----------> [Bayar] <----------+

|

v

Transaksi "bayar" + Pembayaran

Antrian "diambil"


### Aturan keras

1. **Antrian hanya untuk layanan cuci.** Bon kantin murni langsung `Bayar`, tanpa antrian dan tanpa pit.
2. **Pit TIDAK dipilih saat mobil masuk.** Kasir hanya mencatat plat + layanan.
   Pit ditentukan saat tukang mulai mengerjakan, dengan melihat pit mana yang kosong.
   Alasannya: mobil bisa menunggu di halaman; pit baru relevan ketika dicuci.
3. **Bon `open` tidak boleh dihapus** โ€” hanya bisa dibayar atau dibatalkan. Kalau pelanggan pergi, batalkan dengan alasan.
4. **Kantin yang dibayar sendiri tetap item bon itu**, ditandai lunas; tidak ikut total tagihan cuci.
5. **Komisi tukang dihitung server** saat antrian selesai, bukan dari app.
6. **Kasir bisa melayani banyak mobil sekaligus**: kartu antrian dibuka sebagai panel detail, bukan halaman penuh.

Aturan keras

1. Antrian hanya untuk layanan cuci. Bon kantin murni langsung Bayar, tanpa antrian dan tanpa pit.

2. Pit TIDAK dipilih saat mobil masuk. Kasir hanya mencatat plat + layanan.

Pit ditentukan saat tukang mulai mengerjakan, dengan melihat pit mana yang kosong.

Alasannya: mobil bisa menunggu di halaman; pit baru relevan ketika dicuci.

3. **Bon open tidak boleh dihapus** โ€” hanya bisa dibayar atau dibatalkan. Kalau pelanggan pergi, batalkan dengan alasan.

4. Kantin yang dibayar sendiri tetap item bon itu, ditandai lunas; tidak ikut total tagihan cuci.

5. Komisi tukang dihitung server saat antrian selesai, bukan dari app.

6. Kasir bisa melayani banyak mobil sekaligus: kartu antrian dibuka sebagai panel detail, bukan halaman penuh.

Model Data Tambahan

Model data tambahan untuk alur ini

Tiga kebutuhan yang belum ada di schema sekarang.

1. Item bisa dibayar sendiri

model TransaksiItem {
  // ...kolom lama...
  bayarSendiri Boolean @default(false)   // kantin yang dibayar saat dipesan
  bayarAt      DateTime?                 // kapan item itu lunas
}

model TransaksiItem {

// ...kolom lama...

bayarSendiri Boolean @default(false) // kantin yang dibayar saat dipesan

bayarAt DateTime? // kapan item itu lunas

}

Total tagihan cuci = `sum(subtotal) where tipe='cuci' or bayarSendiri=false`.

### 2. Tukang per pekerjaan
`Antrian.tukangId` sudah ada (satu tukang). Kalau nanti dua orang satu mobil:

Total tagihan cuci = sum(subtotal) where tipe='cuci' or bayarSendiri=false.

2. Tukang per pekerjaan

Antrian.tukangId sudah ada (satu tukang). Kalau nanti dua orang satu mobil:

model AntrianTukang {
  id        String @id @default(cuid())
  antrianId String
  userId    String
  porsi     Int    @default(100)   // persen komisi
  komisi    Int    @default(0)
  @@unique([antrianId, userId])
}

model AntrianTukang {

id String @id @default(cuid())

antrianId String

userId String

porsi Int @default(100) // persen komisi

komisi Int @default(0)

@@unique([antrianId, userId])

}


### 3. Riwayat status antrian

3. Riwayat status antrian

model AntrianLog {
  id        String   @id @default(cuid())
  antrianId String
  dari      String
  ke        String
  olehId    String?
  createdAt DateTime @default(now())
}

model AntrianLog {

id String @id @default(cuid())

antrianId String

dari String

ke String

olehId String?

createdAt DateTime @default(now())

}

Dipakai untuk menjawab "mobil ini sudah berapa lama menunggu" dan "siapa yang menyelesaikan".

### Endpoint baru yang dibutuhkan

| Method | Path | Untuk |
|---|---|---|
| POST | `/v1/transaksi/:id/item` | tambah kantin ke bon terbuka (hitung ulang total di server) |
| POST | `/v1/transaksi/:id/item/:itemId/bayar` | bayar item kantin saja |
| DELETE | `/v1/transaksi/:id/item/:itemId` | hapus item (hanya bila belum dibayar) |
| POST | `/v1/transaksi/:id/bayar` | **sudah ada** โ€” dipakai Tahap 2 |
| PATCH | `/v1/antrian/:id` | tukang, mulai, selesai |
| GET | `/v1/antrian?status=open` | daftar "belum dibayar" untuk mobil ditinggal |

Dipakai untuk menjawab "mobil ini sudah berapa lama menunggu" dan "siapa yang menyelesaikan".

Endpoint baru yang dibutuhkan

| Method | Path | Untuk |

|---|---|---|

| POST | /v1/transaksi/:id/item | tambah kantin ke bon terbuka (hitung ulang total di server) |

| POST | /v1/transaksi/:id/item/:itemId/bayar | bayar item kantin saja |

| DELETE | /v1/transaksi/:id/item/:itemId | hapus item (hanya bila belum dibayar) |

| POST | /v1/transaksi/:id/bayar | sudah ada โ€” dipakai Tahap 2 |

| PATCH | /v1/antrian/:id | tukang, mulai, selesai |

| GET | /v1/antrian?status=open | daftar "belum dibayar" untuk mobil ditinggal |

Rencana Tahap 2 sampai 5

Rencana pengerjaan bertahap

Tiap tahap bisa dipakai sendiri; tidak ada tahap yang menunggu tahap lain untuk dirilis, kecuali disebut.

Tahap 2 โ€” Bayar dari kartu antrian

Tujuan: alur inti tertutup (mobil bisa dicuci, selesai, lalu dibayar).

  • Kartu antrian dapat tombol Bayar โ†’ buka layar bayar terisi item bon itu
  • POST /v1/transaksi/:id/bayar dipakai dari app (endpoint sudah ada di server)
  • Setelah lunas: status bayar, Antrian diambil, struk nota tercetak
  • Sisa pekerjaan server: pastikan antrian ikut ditutup

Selesai bila: mobil masuk antrian โ†’ selesai โ†’ bayar โ†’ struk keluar, tanpa membuat bon baru.

Tahap 3 โ€” Kantin menempel ke tagihan terbuka

Tujuan: pelanggan bisa memesan selama antri; kasir tetap melayani mobil lain.

  • Tombol Tambah pesanan di kartu (buka daftar produk, bukan layar kasir)
  • Dua pilihan per pesanan: Bayar sekarang atau Masukkan ke tagihan
  • Server menambah item + menghitung ulang total (POST /v1/transaksi/:id/item)
  • Laporan memisah kantin yang sudah lunas dari yang ikut tagihan cuci

Selesai bila: satu mobil punya 2 pesanan kantin, satu dibayar sendiri satu masuk tagihan, struk akhir menunjukkan keduanya dengan benar.

Tahap 4 โ€” Tukang & bagi hasil

Tujuan: komisi per tukang bisa dipakai menghitung bagi hasil.

  • Pilih tukang saat memasukkan antrian (atau saat mulai)
  • Tombol Mulai / Selesai mencatat waktu
  • Komisi = PaketLayanan.komisi ร— qty, disimpan saat selesai
  • Laporan periode: komisi per tukang + jumlah mobil

Selesai bila: satu periode menampilkan komisi per nama tukang dan angkanya cocok dengan hitungan manual.

Tahap 5 โ€” Mobil ditinggal

Tujuan: mobil yang dicuci pagi dan diambil sore tidak hilang dari pandangan.

  • Filter Belum dibayar di tab Antrian
  • Kotak cari plat menembus seluruh periode
  • Label jam masuk + berapa lama menunggu

Selesai bila: mobil dari pagi bisa ditemukan lewat plat dan dibayar sore hari.

Laporan Keuangan Terpisah (Cuci vs Kantin)

Pemisahan laporan: Kas Cuci vs Kas Kantin

Keputusan (7 Nov 2026): uang cuci dan uang kantin tidak dicampur, termasuk di tutup kasir.

Satu shift bisa menutup dengan dua saldo berbeda.

Rumus

Kas Cuci   = sum(item.subtotal where tipe in (cuci, addon)) - diskon
Kas Kantin = sum(item.subtotal where tipe = kantin)
Total bon  = Kas Cuci + Kas Kantin   (yang dibayar pelanggan sekali)

Kas Cuci = sum(item.subtotal where tipe in (cuci, addon)) - diskon

Kas Kantin = sum(item.subtotal where tipe = kantin)

Total bon = Kas Cuci + Kas Kantin (yang dibayar pelanggan sekali)


`diskon` saat ini berlaku untuk satu bon. **Aturan baru:** diskon hanya mengurangi **Kas Cuci**.
Alasan: diskon promo cuci tidak boleh mengurangi setoran kantin ke pemilik warung/makanan.

### Arti angka di tutup kasir

diskon saat ini berlaku untuk satu bon. Aturan baru: diskon hanya mengurangi Kas Cuci.

Alasan: diskon promo cuci tidak boleh mengurangi setoran kantin ke pemilik warung/makanan.

Arti angka di tutup kasir

KAS CUCI
  sistem (dari transaksi)   Rp 450.000
  fisik (dihitung kasir)    Rp 450.000
  selisih                   Rp 0

KAS KANTIN
  sistem                    Rp 186.000
  fisik                     Rp 186.000
  selisih                   Rp 0

TOTAL DITERIMA             Rp 636.000

KAS CUCI

sistem (dari transaksi) Rp 450.000

fisik (dihitung kasir) Rp 450.000

selisih Rp 0

KAS KANTIN

sistem Rp 186.000

fisik Rp 186.000

selisih Rp 0

TOTAL DITERIMA Rp 636.000


Kalau selisih hanya muncul di salah satu, penyebabnya bisa langsung ditelusuri (uang kembalian cuci vs uang warung).

### Laporan harian & periode

| Tab | Isi |
|---|---|
| **Cuci** | omzet cuci, jumlah mobil, paket terlaris, komisi tukang, rata-rata durasi |
| **Kantin** | omzet kantin, jumlah pesanan, produk terlaris, kantin lepas vs kantin ikut mobil |
| **Gabungan** | total omzet + perbandingan dua sumber (grafik) |

**Kantin dipisah lagi dua baris** karena sumber uangnya beda:

Kalau selisih hanya muncul di salah satu, penyebabnya bisa langsung ditelusuri (uang kembalian cuci vs uang warung).

Laporan harian & periode

| Tab | Isi |

|---|---|

| Cuci | omzet cuci, jumlah mobil, paket terlaris, komisi tukang, rata-rata durasi |

| Kantin | omzet kantin, jumlah pesanan, produk terlaris, kantin lepas vs kantin ikut mobil |

| Gabungan | total omzet + perbandingan dua sumber (grafik) |

Kantin dipisah lagi dua baris karena sumber uangnya beda:

Kantin ikut mobil   (dibayar bersama cuci)
Kantin lepas        (dibayar sendiri, tanpa cuci)

Kantin ikut mobil (dibayar bersama cuci)

Kantin lepas (dibayar sendiri, tanpa cuci)


### Struk

Satu mobil yang memesan makanan dan cuci tetap **satu struk**, tetapi totalnya dipisah:

Struk

Satu mobil yang memesan makanan dan cuci tetap satu struk, tetapi totalnya dipisah:

------------------------------
Reguler              15.000
Es Teh Manis          4.000
------------------------------
TOTAL CUCI           15.000
TOTAL KANTIN          4.000
------------------------------
TOTAL                19.000
Tunai                20.000
Kembali               1.000

------------------------------

Reguler 15.000

Es Teh Manis 4.000

------------------------------

TOTAL CUCI 15.000

TOTAL KANTIN 4.000

------------------------------

TOTAL 19.000

Tunai 20.000

Kembali 1.000


Bila hanya kantin (lepas), struk memakai judul **STRUK KANTIN** dan nomor seri `K-0001`.

### Yang perlu diubah di server (belum dikerjakan)

| Bagian | Perubahan |
|---|---|
| `laporan.harian` | sudah menghitung `subtotalCuci` & `subtotalKantin` โ€” tambahkan blok `kas: { cuci, kantin }` |
| `laporan.periode` | idem, tambah `kantinLepas` vs `kantinMobil` |
| `shift.tutup` | simpan dua angka fisik + dua selisih, bukan satu |
| `Transaksi` | tidak perlu kolom baru โ€” kantin lepas dikenali dari tidak adanya item cuci |
| Nomor bon | seri terpisah: `A-` untuk bon berantrian, `K-` untuk kantin lepas |

### Yang perlu diubah di app (belum dikerjakan)

- Tab **Laporan**: pemilih `Cuci | Kantin | Gabungan`
- Tutup shift: dua kolom hitung fisik
- Kasir: tombol **Pesanan kantin** (nomor `K-`), tanpa plat dan tanpa antrian
- Struk: baris `TOTAL CUCI` / `TOTAL KANTIN` bila bon berisi keduanya

Bila hanya kantin (lepas), struk memakai judul STRUK KANTIN dan nomor seri K-0001.

Yang perlu diubah di server (belum dikerjakan)

| Bagian | Perubahan |

|---|---|

| laporan.harian | sudah menghitung subtotalCuci & subtotalKantin โ€” tambahkan blok kas: { cuci, kantin } |

| laporan.periode | idem, tambah kantinLepas vs kantinMobil |

| shift.tutup | simpan dua angka fisik + dua selisih, bukan satu |

| Transaksi | tidak perlu kolom baru โ€” kantin lepas dikenali dari tidak adanya item cuci |

| Nomor bon | seri terpisah: A- untuk bon berantrian, K- untuk kantin lepas |

Yang perlu diubah di app (belum dikerjakan)

  • Tab Laporan: pemilih Cuci | Kantin | Gabungan
  • Tutup shift: dua kolom hitung fisik
  • Kasir: tombol Pesanan kantin (nomor K-), tanpa plat dan tanpa antrian
  • Struk: baris TOTAL CUCI / TOTAL KANTIN bila bon berisi keduanya

Mockup Tata Letak Baru (7 Nov 2026)

Mockup tata letak baru

Dua arah warna, isi layar sama supaya yang dibandingkan tampilannya, bukan teksnya.

Lima layar per arah: Kasir, Antrian (tablet), Antrian (HP), Pesanan kantin, Laporan.

Yang diubah dibanding app sekarang

| Sekarang | Baru |

|---|---|

| Kasir: plat + jenis + pit + layanan + add-on + kantin + bon kanan | Kasir hanya memulai: plat, jenis, layanan, satu tombol |

| Antrian: kartu, ubah status, cetak tiket | Antrian jadi pusat kerja: daftar + panel detail, tambah pesanan, mulai, bayar |

| Kantin menempel di Kasir | Kantin menempel ke mobil (dari kartu) atau jadi pesanan lepas bernomor K- |

| Laporan satu angka | Dua buku: Kas Cuci & Kas Kantin, masing-masing sistem/fisik/selisih |

| HP: halaman penuh per layar | HP: daftar penuh + sheet detail dari bawah |

Arah A โ€” Terang ยท Biru Bersih (lanjutan gaya sekarang)

#2563EB biru, #EA580C oranye, latar #F8FAFC, kartu putih, radius 14px.

Tidak mengubah identitas visual app, hanya alurnya.

Arah B โ€” Gelap ยท Kontras Tinggi

Latar #0B1220, kartu #131C2E, primer #38BDF8, badge lebih menyala.

Alasan: HP kasir dipakai di area basah/terbuka; latar gelap lebih tahan pantulan sinar,

tetapi berbeda jauh dari tampilan sekarang dan perlu penyesuaian semua layar.

KEPUTUSAN: arah A dipilih (7 Nov 2026)

A โ€” Terang ยท Biru Bersih dipakai. Arah B (gelap) tidak dilanjutkan.

Token warna tetap seperti app sekarang: #2563EB primer, #EA580C aksen, latar #F8FAFC, kartu #FFFFFF, radius 14px, teks #1E293B, redup #475569.

Catatan dari pemeriksaan gambar

  • Isi tiap layar diperiksa otomatis: tidak ada teks terpotong, tidak ada elemen bertumpuk,

tidak ada kartu terpotong tepi frame, kontras teks redup lolos di kedua arah warna.

  • Satu perbaikan sudah dilakukan: baris "Dipilih: โ€ฆ" di layar Kasir dibuat lebih tegas

karena sebelumnya terlalu redup untuk informasi yang menentukan.

Mockup A terang

Mockup A terang

Mockup B gelap

Mockup B gelap